feat(items): front openclaw with a sign-in proxy, drop the token and pairing - #8
Open
amiralis1365 wants to merge 6 commits into
Open
feat(items): front openclaw with a sign-in proxy, drop the token and pairing#8amiralis1365 wants to merge 6 commits into
amiralis1365 wants to merge 6 commits into
Conversation
Deploying this item produced a Control UI nobody could get into. The
browser authenticated, then stopped at "device pairing required
(requestId: ...)" and named a CLI on the Gateway host to approve it.
Device pairing is a second gate, evaluated only after gateway token auth
has already succeeded. A new browser's request can be approved only by an
already-paired admin session, and on a fresh deployment nothing is ever
paired -- so the item deadlocked, and the remedy the error names needs a
host shell a one-click deployment does not have. Neither the token nor
the origin allowlist was at fault; both already pass before this point.
Satisfy the gate rather than remove it. The component now runs a watcher
beside the gateway that polls `devices list --json` and approves pending
requests. Upstream anticipates this: `shouldPreserveLocalCliSharedAuthScopes`
carries a dedicated `cli_container_local` locality that preserves operator
scopes for a token-authenticated CLI inside the gateway's own container,
so `devices approve` works over loopback without pairing of its own.
Rejected `gateway.controlUi.dangerouslyDisableDeviceAuth`, which clears
the same gate by discarding device identity altogether: browsers keep
their device keypair and revocable device token this way, `openclaw
security audit` stays clean, and the mechanism survives 2026.8.x, where
that key is retired and inert and would silently restore the deadlock on
an image bump. Also verified against the pinned release that
`allowInsecureAuth` ("does not bypass pairing checks", localhost-only)
and `nodes.pairing.autoApproveCidrs` (never applies to browser clients)
are not alternatives, despite both being widely cited as such.
Two greps rather than a JSON parser because the image ships no jq and a
node -e script cannot be quoted inside this scalar. `requestId` appears
only on pending entries -- paired rows carry deviceId, displayName,
roles, scopes, tokens and IP -- so the match cannot touch an existing
device.
The listing's claim that you just enter the token on first visit was
false for every deployment; it now describes both gates and says plainly
that the token is what guards an admin surface.
Verified: typecheck clean, full corpus 377/377; the folded scalar
resolves to one well-formed shell string whose --batch-json argument
parses as the three intended ops; `sh -n` accepts the inner script; and
the grep pipeline extracts exactly the pending requestId from a payload
carrying both a pending entry and a paired device, ignoring the latter's
UUID. Not verified -- no container runtime in the devcontainer: the live
smoke test that the watcher actually clears the pairing screen.
Closes #6
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpjD9Pkxh8LBhJPJQLzpx3
The watcher polled `devices list --json` every five seconds. That call is a cold Node start against a large CLI bundle, and the approve path is a second one, so a user's first connect sat in apparent failure for 10-15 seconds before the Control UI came up -- confirmed on a live deployment. Read the persisted pending-pairing file instead. The gateway writes each pending request to <stateDir>/devices/pending.json before it rejects the browser, and `resolveStateDir` resolves to OPENCLAW_STATE_DIR, else OPENCLAW_HOME/HOME joined with `.openclaw` -- which is exactly where this item mounts its config volume, so the path agrees by construction rather than by coincidence. Detection now costs two greps, spawns no Node process while nothing is pending, and leaves only one CLI start in the user's path. Poll drops to 1s because it is no longer expensive. Reading pending.json is also strictly better isolated than the previous `devices list` output: paired devices live in a separate file, so a paired entry cannot be matched at all. Verified: full corpus 377/377, typecheck clean; the folded scalar still resolves to the three intended config ops; `sh -n` accepts the inner script; and against fixtures written in the persisted shape the pipeline extracts exactly the pending requestId, yields nothing for paired.json, and handles the file being absent (it does not exist until a browser first pairs). The previous approach was confirmed working end-to-end on a live deployment; this change alters only how a pending request is detected, not what is approved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpjD9Pkxh8LBhJPJQLzpx3
…ring watcher
Replace the in-container `devices approve` watcher with
`gateway.controlUi.dangerouslyDisableDeviceAuth`. The intended posture for
this item is that anyone holding the gateway token gets in from any
browser, with no device enrolled or allowlisted, and no background process
load-bearing for reachability.
Gateway auth is untouched, despite the key's name. Reading the connect
path in the 2026.7.1 bundle, three gates run in order: device identity
(allowBypass admits), then an unconditional
`if (!authOk) { rejectUnauthorized(authResult); return; }`, then the
pairing gate (allowBypass skips). Gate two carries no isControlUi test and
no allowBypass exemption and sits between the two the key affects, so the
token check stays fully enforced while device identity and pairing go
away.
The watcher it replaces worked and was confirmed live, but it made
reachability depend on a polling loop and put a cold CLI start in front of
every first connect. Traded for a declarative key and an instant first
load. The cost is stated in both files: no second factor, no per-device
revocation, and browsers re-present the token each session rather than
holding a stored device token.
The image tag is now load-bearing and says so. 2026.7.1 is the last stable
release honouring this key: its `shouldSkipControlUiPairing` ends
`return role === "operator" && policy.allowBypass`, while 2026.8.1 and
2026.8.2 both end `return null`, and upstream calls the key "a retired
break-glass input, now fully inert" with `doctor --fix` deleting it. On
2026.8.x the controlUi schema no longer offers a device-auth toggle at
all, and the surviving bypasses are trusted-proxy `deviceAutoApprove`
(needs an identity-injecting edge) or node-role pairing policy (excludes
browsers). A tag bump therefore silently restores the deadlock; the
comment says what has to change alongside it.
Verified: full corpus 377/377, typecheck clean, `sh -n` accepts the inner
script, and the folded scalar resolves to four config ops with
dangerouslyDisableDeviceAuth carried as a JSON boolean rather than a
string.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpjD9Pkxh8LBhJPJQLzpx3
…pairing Replace "paste a 64-char token, then wait for a device-pairing workaround" with "sign in with a username and password". The item becomes two nodes: a Caddy proxy that authenticates the browser and holds the deployment's only public endpoint, and the OpenClaw gateway behind it on the private mesh. The proxy attaches the authenticated identity as X-Forwarded-User. The gateway runs `gateway.auth.mode: "trusted-proxy"` and enrols the browser's device automatically on the strength of that header, so pairing resolves inside the handshake -- upstream: "New browser operator devices (including Control UI and WebChat) ... resolve automatically". No error reaches the user and no approval loop is needed, which is what the previous in-container watcher existed to paper over. This is also what unblocks the version. `dangerouslyDisableDeviceAuth` is retired and inert from 2026.8.1, so keeping it pinned the item to 2026.7.1; `deviceAutoApprove` is 2026.8.x-only, so the two move together. Pin is now 2026.8.2, whose release hardened the migration path that got PR #3 reverted. Readiness moves to /startupz, which upstream designates for traffic admission, so one lapsed channel credential can no longer 503 a healthy Control UI off the routing table. deviceAutoApprove.scopes names operator.admin deliberately. The default set omits it, which would leave the operator of a single-tenant deployment unable to change settings. Upstream reports it critical because on a shared gateway every proxy-authenticated user could self-grant admin; here the proxy admits exactly one credential, so those two sets coincide. The gateway endpoint is PRIVATE, which is load-bearing rather than tidy: trusted-proxy mode trusts a header instead of a secret, so a public gateway port would be an unauthenticated route straight past the proxy. It also justifies the RFC1918 `trustedProxies` ranges -- they say who may assert identity headers, and nothing outside the mesh can reach the port to try. Verified against real Caddy 2.10.2, not documentation: - `caddy validate` accepts the generated Caddyfile. - Health endpoint answers 200 unauthenticated; the protected route answers 401 with no credentials and 401 with a wrong password. - Correct credentials reach the upstream carrying X-Forwarded-User: admin. - A request forging `X-Forwarded-User: attacker@evil.test` arrives upstream as `admin`. `header_up` overwrites, so the identity header cannot be spoofed -- the property the whole design rests on. - Caddy sets X-Forwarded-Proto and X-Forwarded-Host itself, so the explicit header_up lines it warned were redundant are gone. Also verified: corpus 377/377, typecheck clean, both folded scalars resolve to well-formed shell accepted by `sh -n`, and the gateway's --batch-json argument parses as the eight intended config ops. Not verified -- no container runtime here: the live deploy, and the platform resolving the two-node connection cycle (proxy.publicUrl -> gateway, and gateway.address -> proxy), which component spec 6.2 permits but only the platform can confirm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpjD9Pkxh8LBhJPJQLzpx3
The publish gate rejected the blueprint with: connection to_input='openclawUpstream' is not a declared input of node 'proxy' `openclawUpstream` is a declared CONNECTION input of auth-proxy.yaml, and the local suite's ERR_UNKNOWN_INPUT rule passes against this exact blueprint, so the gate was not reading this file. Every document still declared `metadata.version: 1`, which is the version already published for this item, so nothing signalled that the contract had changed and the gate resolved node `proxy` against a previously published version-1 record. Bump all four documents to 2. The contract genuinely changed shape: the item went from one node to two, the gateway lost `gatewayToken`, its `publicOrigin` moved from USER to CONNECTION, it gained an `address` output, and its endpoint went PRIVATE. Reusing a version across that is what the monotonic-version check exists to catch. Also correct two comments the proxy rewrite left stale: the image-tag note still warned about a pairing watcher that no longer exists (the coupling is now to `deviceAutoApprove`, and it runs in both directions -- the tag cannot go backward either), and the `config set` note still cited /readyz after readiness moved to /startupz. Verified: corpus 377/377, typecheck clean. Not verified: whether the version bump is sufficient. If the gate still rejects, the remaining candidate is that it syncs from main, which does not carry components/auth-proxy.yaml until this branch merges. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpjD9Pkxh8LBhJPJQLzpx3
Second attempt at the publish-gate rejection:
connection to_input='openclawUpstream' is not a declared input of
node 'proxy'
The version bump did not clear it, and the gate reports v2, so it is
reading these documents rather than a stale record. Two changes, both
cheap, neither certain:
Declare `gateway` before `proxy`. Map order is graph order, and `proxy`
consumes `gateway.address`. The graph is a legal cycle in either order
(blueprint §4.2), but a validator walking the map in order now meets the
producer's output before the wire that reads it.
Omit `ui` on both CONNECTION inputs instead of writing `ui: null`. §6.1
requires it to be null and both spellings satisfy that, but omission cannot
be mistaken for a present-but-empty value by a consumer that distinguishes
the two.
Worth recording for whoever picks this up: these are the only two
`fromRole` lines in the corpus, and openclaw is its only multi-component
item, so the publish gate's connection path has never been exercised by
this catalog. The documents match the spec -- `openclawUpstream` is a
declared CONNECTION input of auth-proxy.yaml, and the suite's
ERR_UNKNOWN_INPUT rule ("the map key names no input of the consumer")
passes against this exact blueprint -- so if the gate still rejects, the
next step is the platform rather than these files.
Verified: corpus 377/377, typecheck clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpjD9Pkxh8LBhJPJQLzpx3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Alternative to #7. Replaces "paste a 64-char token, then work around device pairing" with sign in with a username and password.
The item becomes two nodes: a Caddy proxy holding the only public endpoint, and the OpenClaw gateway behind it on the private mesh.
How it works
The proxy authenticates the browser and attaches the identity as
X-Forwarded-User. The gateway runsgateway.auth.mode: "trusted-proxy"and enrols the device automatically on the strength of that header, so pairing resolves inside the handshake — upstream: "New browser operator devices (including Control UI and WebChat) … resolve automatically."No error ever reaches the user, and no approval loop is needed. That is what the in-container watcher on #7 existed to paper over.
It also unblocks the version
dangerouslyDisableDeviceAuthis retired and inert from 2026.8.1, which pinned #7 to 2026.7.1.deviceAutoApproveis 2026.8.x-only. The two move together, so this lands on 2026.8.2 — the release that hardened the migration path #3 was reverted over. Readiness moves to/startupz, which upstream designates for traffic admission, so one lapsed channel credential can no longer 503 a healthy Control UI out of routing.Verified against real Caddy 2.10.2, not documentation
I downloaded the pinned binary and ran the generated config:
caddy validateon the generated CaddyfileX-Forwarded-User: adminX-Forwarded-User: attacker@evil.testadminThat last row is the property the whole design rests on:
header_upwithout a+prefix sets the header, so a browser cannot smuggle its own identity past the proxy. Caddy also setsX-Forwarded-Proto/X-Forwarded-Hostitself, so the explicit lines it warned were redundant are gone.Fixed during verification: the first Caddyfile used one-line blocks (
handle /x { respond "ok" 200 }), which the grammar rejects — a block-opening{must end its line.caddy validatecaught it.Design notes
trustedProxiesranges: they say who may assert identity headers, and nothing outside the mesh can reach the port to try.deviceAutoApprove.scopesnamesoperator.admindeliberately. The default set omits it, which would leave the operator of a single-tenant deployment unable to change settings. Upstream reports it critical because on a shared gateway every proxy-authenticated user could self-grant admin; here the proxy admits exactly one credential, so those two sets coincide.patternon the install form, and stored only as a bcrypt hash in the running container.Not verified
No container runtime here, so: the live deploy, and whether the platform resolves the two-node connection cycle (
proxy.publicUrl→ gateway,gateway.address→ proxy). Component spec §6.2 explicitly permits that cycle — an output derives from its own workload’s addressing and never from an inbound connection — but only the platform can confirm it.Corpus 377/377, typecheck clean, both folded scalars accepted by
sh -n.Relationship to #7
#7 is smaller and confirmed working on a live deployment, but stuck on 2026.7.1 and keeps the token paste. This is the larger change that gets the latest release and a real sign-in. They are alternatives — merging this should close #7.
🤖 Generated with Claude Code
https://claude.ai/code/session_01LpjD9Pkxh8LBhJPJQLzpx3